AWS Systems Manager State ManagerのEC2インスタンスへの関連付け成功/失敗イベントのメール通知を実装してみた
Systems Manager Agentの自動更新結果をメール通知させたい
こんにちは、のんピ(@non____97)です。
皆さんは、Systems Manager Agent(以降SSM Agent)の自動更新は行なっていますか? 私は不要になったEC2インスタンスはすぐさま削除派なのでしていません。
ただし、SSM Agentなどの各種ソフトウェアを本番運用するとなると、そのソフトウェアのバージョン管理が必要となります。バージョン管理を行い、定期的にバージョンアップをしなければ、脆弱性やバグが放置されたままとなってしまいます。また、環境によってバージョンが混在していたりすると、トラブルシューティングに時間がかかる原因となります。
SSM Agentではバージョン管理が簡単にできるように、ワンクリックで SSM Agent の自動更新ができるようになっています。
こちらの記事でも触れていますが、実際の運用で気になるところと言えば、「正しくSSM Agentがバージョンアップされたか」だと思います。
ワンクリックで SSM Agent の自動更新をすると、SSM State Managerの関連付けが自動で作成されます。そこで、SSM State ManagerのEC2インスタンスへの関連付け成功/失敗イベントのメール通知を実装してみようと思います。
いきなりまとめ
- SSM State Managerで処理が完了したタイミングでEventBridgeで検知可能
- SSM State ManagerのログはS3バケットに出力可能
- CloudWatch Logsに出力することはできない
- S3バケットにログ出力するためには、EC2インスタンスにログ出力先のS3バケットにオブジェクトをPUTする権限が必要
- SSM State Managerで関連付けを行ったタイミングで起動していないEC2インスタンスについては、そもそも処理が実行されない
検証の環境
今回の検証の構成は以下の通りです。
EC2インスタンス2台に対して、SSM Agentの自動更新を行い、正しくバージョンアップされるかを確認します。
単純に自動更新されるのを確認するのも面白くないので、以下パターンでも検証してみます。
- EC2インスタンスを1台停止してみた状態で関連付けを実行した場合、実行結果はどのようになるのか
- 1台は成功で1台は失敗?
- 1台成功で1台はそもそも実行されない?
- バージョンアップ先に存在しないバージョンを指定して関連付けを実行した場合、実行結果はどのようになるのか
- 正しく失敗のメール通知が届く?
やってみた
各種リソースのデプロイ
最近AWS CDKを触っていない気がしたので、AWS CDKで各種リソースをデプロイします。
ただし、SSM Agentの自動更新はデプロイ後にマネージメントコンソールからワンクリックで有効化します。
なお、AWS CDKのコードはかなり長くなってしまったので、以下に折りたたみます。
AWS CDK関連の情報
> tree . ├── .gitignore ├── .npmignore ├── README.md ├── bin │ └── app.ts ├── cdk.json ├── jest.config.js ├── lib │ ├── ec2-instances-stack.ts # EC2インスタンスと、SSM State Managerのログの出力先のS3バケットを作成するスタック │ ├── email-notifications-stack.ts # メール通知用のSNSトピック、SNSサブスクリプションを作成するスタック │ └── lambda-functions-stack.ts # SSM State ManagerのEC2インスタンスへの関連付け成功/失敗イベントのメール通知をするLambda関数とEventBridgeの設定をするスタック ├── package-lock.json ├── package.json ├── src │ └── lambda │ └── functions │ └── email-nortifications.ts # メール通知用のLambda関数 ├── test │ └── app.test.ts └── tsconfig.json 6 directories, 14 files
#!/usr/bin/env node import "source-map-support/register"; import * as cdk from "@aws-cdk/core"; import { Ec2InstancesStack } from "../lib/ec2-instances-stack"; import { LambdaFunctionsStack } from "../lib/lambda-functions-stack"; import { EmailNotificationsStack } from "../lib/email-notifications-stack"; const app = new cdk.App(); const ec2InstancesStack = new Ec2InstancesStack(app, "Ec2InstancesStack"); const emailNotificationsStack = new EmailNotificationsStack( app, "EmailNotificationsStack" ); const lambdaFunctionsStack = new LambdaFunctionsStack( app, "LambdaFunctionsStack", { emailSnsTopic: emailNotificationsStack.emailSnsTopic, } );
import * as cdk from "@aws-cdk/core"; import * as s3 from "@aws-cdk/aws-s3"; import * as iam from "@aws-cdk/aws-iam"; import * as ec2 from "@aws-cdk/aws-ec2"; export class Ec2InstancesStack extends cdk.Stack { constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); // Create S3 Bucket for State Manager log const stateManagerLogsBucket = new s3.Bucket( this, "StateManagerLogsBucket", { encryption: s3.BucketEncryption.S3_MANAGED, blockPublicAccess: new s3.BlockPublicAccess({ blockPublicAcls: true, blockPublicPolicy: true, ignorePublicAcls: true, restrictPublicBuckets: true, }), } ); // Create SSM IAM role const ssmIamRole = new iam.Role(this, "SsmIamRole", { assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"), managedPolicies: [ iam.ManagedPolicy.fromAwsManagedPolicyName( "AmazonSSMManagedInstanceCore" ), ], }); // Create IAM Policy for SSM State Manager Logs const exportStateManagerLogsBucketIamPolicy = new iam.Policy( this, "ExportStateManagerLogsBucketIamPolicy", { statements: [ new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ["s3:PutObject"], resources: [`${stateManagerLogsBucket.bucketArn}/*`], }), new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ["s3:GetEncryptionConfiguration"], resources: [stateManagerLogsBucket.bucketArn], }), ], } ); // Atach SSM State Manager Logs IAM Policy to SSM IAM Role ssmIamRole.attachInlinePolicy(exportStateManagerLogsBucketIamPolicy); // Create a VPC const vpc = new ec2.Vpc(this, "Vpc", { cidr: "10.0.0.0/24", enableDnsHostnames: true, enableDnsSupport: true, maxAzs: 2, subnetConfiguration: [ { name: "Public", subnetType: ec2.SubnetType.PUBLIC, cidrMask: 27, }, ], }); // Create EC2 instance // AmazonLinux 2 vpc .selectSubnets({ subnetGroupName: "Public" }) .subnets.forEach((subnet, index) => { new ec2.Instance(this, `Ec2Instance${index}`, { machineImage: ec2.MachineImage.latestAmazonLinux({ generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2, }), instanceType: new ec2.InstanceType("t3.micro"), vpc: vpc, role: ssmIamRole, vpcSubnets: vpc.selectSubnets({ subnetGroupName: "Public", availabilityZones: [vpc.availabilityZones[index]], }), }); }); } }
import * as cdk from "@aws-cdk/core"; import * as sns from "@aws-cdk/aws-sns"; import * as snsSubscriptions from "@aws-cdk/aws-sns-subscriptions"; export class EmailNotificationsStack extends cdk.Stack { public readonly emailSnsTopic: sns.Topic; constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const emailAddress: string = this.node.tryGetContext("email-address"); // Create SNS Topic for Email this.emailSnsTopic = new sns.Topic(this, "EmailSNS"); this.emailSnsTopic.addSubscription( new snsSubscriptions.EmailSubscription(emailAddress) ); } }
import * as cdk from "@aws-cdk/core"; import * as iam from "@aws-cdk/aws-iam"; import * as lambda from "@aws-cdk/aws-lambda"; import * as nodejs from "@aws-cdk/aws-lambda-nodejs"; import * as sns from "@aws-cdk/aws-sns"; import * as events from "@aws-cdk/aws-events"; import * as eventsTargets from "@aws-cdk/aws-events-targets"; interface LambdaFunctionsStackProps extends cdk.StackProps { emailSnsTopic: sns.Topic; } export class LambdaFunctionsStack extends cdk.Stack { public readonly emailNortificationsFunction: nodejs.NodejsFunction; constructor( scope: cdk.Construct, id: string, props: LambdaFunctionsStackProps ) { super(scope, id, props); // Declare AWS account ID and region. const { accountId, region } = new cdk.ScopedAws(this); // Create IAM role for Lambda functions for email nortifications const emailNortificationsIamRole = new iam.Role( this, "EmailNortificationsIamRole", { assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"), managedPolicies: [ iam.ManagedPolicy.fromAwsManagedPolicyName( "service-role/AWSLambdaBasicExecutionRole" ), ], } ); // Create IAM Policy for sns publish const emailNortificationsIamPolicy = new iam.Policy( this, "EmailNortificationsIamPolicy", { statements: [ new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ["sns:Publish"], resources: [props.emailSnsTopic.topicArn], }), ], } ); // Atach IAM Policy for sns publish to Lambda functions to email nortifications IAM Role emailNortificationsIamRole.attachInlinePolicy(emailNortificationsIamPolicy); // Lambda function for email nortifications const emailNortificationsFunction = new nodejs.NodejsFunction( this, "EmailNortificationsFunction", { entry: "src/lambda/functions/email-nortifications.ts", runtime: lambda.Runtime.NODEJS_14_X, bundling: { minify: true, }, environment: { ACCOUNT_ID: accountId, REGION: region, SNS_TOPIC: props.emailSnsTopic.topicArn, }, role: emailNortificationsIamRole, } ); // Create EventBridge Rule new events.Rule(this, "EventBriedgeRule", { eventPattern: { source: ["aws.ssm"], detailType: ["EC2 State Manager Instance Association State Change"], detail: { status: ["Success", "Failed"], }, resources: [ `arn:aws:ssm:${region}:${accountId}:document/AWS-UpdateSSMAgent`, ], }, targets: [ new eventsTargets.LambdaFunction(emailNortificationsFunction, { retryAttempts: 3, }), ], }); } }
import { SNSClient, PublishCommand, PublishCommandInput, } from "@aws-sdk/client-sns"; import { Context, Callback } from "aws-lambda"; // ref : https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-events.html interface EventPattern { version: string; id: string; "detail-type": string; source: string; account: string; time: string; region: string; resources: string; detail: any; } const REGION = process.env.REGION; const SNS_TOPIC = process.env.SNS_TOPIC; const snsClient = new SNSClient({ region: REGION }); exports.handler = async ( event: EventPattern, context: Context, callback: Callback ) => { const textParams: PublishCommandInput = { Subject: event["detail-type"], Message: JSON.stringify(event, null, 2), TargetArn: SNS_TOPIC, }; await snsClient .send(new PublishCommand(textParams)) .then(() => { console.log("Message sent"); console.log(textParams); }) .catch((error) => { console.log("Error, message not sent ", error); }); };
{ "name": "app", "version": "0.1.0", "bin": { "app": "bin/app.js" }, "scripts": { "build": "tsc", "watch": "tsc -w", "test": "jest", "cdk": "cdk" }, "devDependencies": { "@aws-cdk/assert": "1.130.0", "@types/aws-lambda": "^8.10.85", "@types/jest": "^26.0.10", "@types/node": "10.17.27", "aws-cdk": "1.130.0", "jest": "^27.3.1", "ts-jest": "^27.0.7", "ts-node": "^9.0.0", "typescript": "~3.9.7" }, "dependencies": { "@aws-cdk/aws-ec2": "^1.130.0", "@aws-cdk/aws-events": "^1.130.0", "@aws-cdk/aws-events-targets": "^1.130.0", "@aws-cdk/aws-iam": "^1.130.0", "@aws-cdk/aws-lambda-nodejs": "^1.130.0", "@aws-cdk/aws-s3": "^1.130.0", "@aws-cdk/aws-sns": "^1.130.0", "@aws-cdk/aws-sns-subscriptions": "^1.130.0", "@aws-cdk/core": "1.130.0", "@aws-sdk/client-sns": "^3.39.0", "aws-lambda": "^1.0.6", "source-map-support": "^0.5.16" } }
npx cdk deploy
で、AWS CDKで定義したリソースをデプロイすると、EC2インスタンスや、Lambda関数、EventBridgeルール、SNSトピックなど各種リソースが作成されていることが確認できます。
EC2インスタンス
Lambda関数
EventBridgeルール
SNSトピック
なお、SNSサブスクリプションを作成したタイミングでAWS Notification - Subscription Confirmation
という件名のメールが来るので、そのメールに記載のリンクをクリックして、サブスクリプションを確認しています。
SSM Agentの自動更新の有効化
それでは、SSM Agentの自動更新の有効化を行います。
SSM Agentの自動更新をワンクリックで有効化する際は、フリートマネージャーからアカウント管理
- SSM エージェントの自動更新
をクリックします。
確認のポップアップが表示されるので、SSM エージェントの自動更新
をクリックします。
SSM エージェントの自動更新
をクリックした後、State Managerを確認すると、SystemAssociationForSsmAgentUpdate
という名前の関連付けが作成されていることが確認できます。
リソースのステータス数
がPending:2
となっていることから、自動更新を有効化した瞬間に、現在起動中のEC2インスタンスに対してSSM Agentのバージョンアップを行なっていることがわかります。
しばらく待つと、EC2 State Manager Instance Association State Change
という件名のメールが2件届きました。メール本文のjsonを確認すると、status
がSuccess
となっていることから、恐らくSSM Agentの自動更新に成功したのだと推測できます。
それでは、実行履歴を確認して、本当にバージョンアップされたかを確認します。
マネージメントコンソールからState Managerを確認すると、先ほどリソースのステータス数
がPending:2
となっていた関連付けが、Success:2
になっていることが分かります。
こちらの関連付けは裏側でAWS-UpdateSSMAgent
というドキュメントをSSM Run Commandで実行しています。その証拠にRun Commandのコマンド履歴を確認すると、コマンドドキュメントがAWS-UpdateSSMAgent
となっている履歴がありました。こちらのステータスも成功
となっています。
インスタンスIDのリンクをクリックすると、詳細なログを確認できるのでクリックします。Outputを確認するとamazon-ssm-agent updated successfully to 3.1.459.0
とあるので、正常にバージョンアップできたことが確認できます。
Successfully downloaded manifest Successfully downloaded updater version 3.1.459.0 Updating amazon-ssm-agent from 3.0.1124.0 to 3.1.459.0 Successfully downloaded https://s3.us-east-1.amazonaws.com/amazon-ssm-us-east-1/amazon-ssm-agent/3.0.1124.0/amazon-ssm-agent-linux-amd64.tar.gz Successfully downloaded https://s3.us-east-1.amazonaws.com/amazon-ssm-us-east-1/amazon-ssm-agent/3.1.459.0/amazon-ssm-agent-linux-amd64.tar.gz Initiating amazon-ssm-agent update to 3.1.459.0 amazon-ssm-agent updated successfully to 3.1.459.0
SSM Agentの自動更新結果のS3バケットへの出力
SSM Run Commandのコマンド履歴は1ヶ月程度しか確認できません。もし、1ヶ月以上前のログを確認したい場合は、S3バケットにログを出力する必要があります。
S3バケットにログを出力する設定も数クリックで完了します。
State Managerから関連付けの編集
をクリックします。
出力オプションのS3 への出力の書き込みを有効にします
をクリックします。すると、ログの出力先となるS3バケット名と、キープレフィックスを入力できるテキストボックスが表示されます。ログの出力先となるS3バケット名とキープレフィックスを入力したら、変更内容を保存
をクリックします。
すると関連付けのステータス
が保留中
となります。
そのまましばらく待つと、リソースのステータス数
がSuccess:2
となります。
SSM Run Commandのコマンド履歴を確認すると、update skipped
と、前回アップデートしたためバージョンアップがスキップされたことが分かります。
Successfully downloaded manifest Successfully downloaded updater version 3.1.459.0 Updating amazon-ssm-agent from 3.1.459.0 to 3.1.459.0 amazon-ssm-agent 3.1.459.0 has already been installed update skipped
ログの出力先として指定したS3バケットを確認すると、正しくログが出力されているようでした。
stdout
オブジェクトを開くと、SSM Run Commandのコマンド履歴と同じ内容が表示されました。ログ出力も問題なく出来ていますね。
メール通知も正常に届いていました。
なお、S3バケットに実行結果を出力する際の注意点は、以下AWS公式ドキュメントに記載がある通り、「S3バケットにログ出力するためには、EC2インスタンスにログ出力先のS3バケットにオブジェクトをPUTする権限が必要」な点です。
S3 バケットにデータを書き込む機能を許可する S3 アクセス権限は、このタスクを実行する IAM ユーザのものではなく、インスタンスに割り当てられたインスタンスプロファイルのものです。詳細については「Systems Manager の IAM インスタンスプロファイルを作成する」を参照してください。さらに、指定された S3 バケットが別の AWS アカウント にある場合は、インスタンスに関連付けられたインスタンスプロファイルに、そのバケットへの書き込みに必要なアクセス許可があることを確認してください。
そのため、今回EC2インスタンスにアタッチしているIAMロールは、AmazonSSMManagedInstanceCore
とは別に以下のインラインポリシーをアタッチしています。
{ "Version": "2012-10-17", "Statement": [ { "Action": "s3:PutObject", "Resource": "arn:aws:s3:::ec2instancesstack-statemanagerlogsbucket0a400980-1g7q2lc0h72hc/*", "Effect": "Allow" }, { "Action": "s3:GetEncryptionConfiguration", "Resource": "arn:aws:s3:::ec2instancesstack-statemanagerlogsbucket0a400980-1g7q2lc0h72hc", "Effect": "Allow" } ] }
EC2インスタンスを1台停止してみた状態で関連付けを実行
EC2インスタンスを1台停止してみた状態で関連付けを実行した場合も検証してみます。
1台は成功で1台は失敗するのか。それとも、1台成功で1台はそもそも実行されないのかが判断つかなかったので、その検証になります。
まず、1台EC2インスタンスを停止させました。
この状態でState Managerから関連付けを今すぐ適用
をクリックしました。
しばらく待つと、リソースのステータス数
がSuccess:1
になっていることが分かります。どうやら1台成功で1台はそもそも実行されないようですね。
Run Commandのコマンド履歴やS3バケットに出力されたログを見ても、1台分のログしか記録されていませんでした。
Run Commandのコマンド履歴
S3バケットに出力されたログ
メールも成功通知が一件のみ届いていました。
そのため、停止しているEC2インスタンスがあるからといって、停止中のEC2インスタンス分失敗通知が送られる訳ではないことが分かりました。
バージョンアップ先に存在しないバージョンを指定して関連付けを実行
最後に、バージョンアップ先に存在しないバージョンを指定して関連付けを実行した場合に正しく失敗通知が届くのかを確認します。
State Managerから関連付けの編集
をクリックし、パラーメーターのVersion
を100000
と存在しないバージョンを指定して、変更内容を保存
をクリックします。
しばらく待つと、リソースのステータス数
がFailed:1
になっていることが分かります。どうやら意図した通り、バージョンアップに失敗しているようですね。
Run Commandのコマンド履歴を確認すると、Failed to update amazon-ssm-agent to 100000
とあるので、確かにバージョンアップに失敗したことが分かります。
Successfully downloaded manifest Successfully downloaded updater version 3.1.459.0 Updating amazon-ssm-agent from 3.1.459.0 to 100000 amazon-ssm-agent target version 100000 is unsupported on current platform Failed to update amazon-ssm-agent to 100000 No rollback needed
次に、S3バケットに出力されたログを確認しましたが、stderr
オブジェクトは空のようでした。これはエラーメッセージが標準エラー出力に出力されていないためです。そのため、SSM Agentの自動更新の場合は、stderr
オブジェクトがPUTされたイベントを検知して、失敗通知を行うといったことは出来ないと考えます。
メールは失敗通知が一件のみ届いていました。
自動更新系の仕組みを導入する場合は更新処理が失敗したパターンも考えよう
AWS Systems Manager State ManagerのEC2インスタンスへの関連付け成功/失敗イベントのメール通知を実装してみました。
「更新に失敗したまま放置されていて、気づいた時には不具合を踏んでいた」みたいなことがないように、自動更新系の仕組みを導入する場合は更新処理が失敗したパターンも考える必要があると思います。
また、本番運用では通知を受けとって終わりではありません。失敗通知を受けとった場合の対応手順などを用意しておくと、実際に失敗通知が届いた時も慌てずに対応できると考えます。
この記事が誰かの助けになれば幸いです。
以上、AWS事業本部 コンサルティング部の のんピ(@non____97)でした!